新手入門,如有錯誤,歡迎指正~~~
系列文章同步更新於部落格
上一章節已經完成 Signaling server 的部分,
本章會實作簡易一對一視訊聊天室作為專案架構更接近實務應用的範例,
user story:
附上完整程式碼 - github
拆分需求後可能有幾個任務:
head 的部分:
<!-- ./public/index.html -->
<!-- .... -->
<head>
<!-- ... -->
<title>Video Chat With WebRTC</title>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
<script src="/socket.io/socket.io.js"></script>
<script defer src="./js/main.js"></script>
</head>
<!-- .... -->
head 主要是載入三支 JS
body 的部分:
<!-- ./public/index.html -->
<!-- ... -->
<body>
<h1>Video Chat With WebRTC</h1>
<div id="container">
<section>
<h1>Local Tracker</h1>
<video id="localVideo" autoplay></video>
</section>
<section>
<h1>Remote Receiver</h1>
<video id="remoteVideo" autoplay></video>
</section>
<div class="box">
<button onclick="connection()">Connection</button>
<button onclick="calling()">Call</button>
<button onclick="closing()">Hang Up</button>
</div>
</div>
</body>
兩個 Video tag 負責接收本地及遠端的視訊呈現,
三個按鈕分別負責:
connection()
: 建立 socket 連線及事件綁定calling()
: 獲取本機端多媒體數據並建立 p2p connectionclosing()
: 關閉連線接下來會實作上述三個主要功能。
connection()
需要處理的:
function connection() {
socket = io.connect("/");
socket.emit("joinRoom", { username: "test" });
// Socket events
socket.on("newUser", (data) => {
console.log("歡迎新人加入");
console.log(data);
});
socket.on("userLeave", (data) => {
console.log("有人離開了");
console.log(data);
});
socket.on("disconnect", () => {
console.log("你已經斷線");
});
socket.on("offer", handleSDPOffer);
socket.on("answer", handleSDPAnswer);
socket.on("icecandidate", handleNewIceCandidate);
}
接收SDP offer
let peer = null; // RTCPeerConnection
let cacheStream = null; // MediaStreamTrack
// ...略
async function handleSDPOffer(desc) {
console.log("*** 收到遠端送來的offer");
try {
if (!peer) {
createPeerConnection(); // create RTCPeerConnection instance
}
console.log(" = 設定 remote description = ");
await peer.setRemoteDescription(desc);
if (!cacheStream) {
await addStreamProcess(); // getUserMedia & addTrack
}
await createAnswer();
} catch (error) {
console.log(`Error ${error.name}: ${error.message}`);
}
}
接收SDP answer
async function handleSDPAnswer(desc) {
console.log("*** 遠端接受我們的offer並發送answer回來");
try {
await peer.setRemoteDescription(desc)
} catch (error) {
console.log(`Error ${error.name}: ${error.message}`);
}
}
接收ICE candidate
async function handleNewIceCandidate(candidate) {
console.log(`*** 加入新取得的 ICE candidate: ${JSON.stringify(candidate)}`);
try {
await peer.addIceCandidate(candidate);
} catch (error) {
console.log(`Failed to add ICE: ${error.toString()}`);
}
}
加入多媒體數據到RTCPeerConnection instance
// Media config
const mediaConstraints = {
audio: false,
video: {
aspectRatio: {
ideal: 1.333333, // 3:2 aspect is preferred
},
},
};
// ...略
async function addStreamProcess() {
let errMsg = "";
try {
console.log("獲取 local media stream 中 ...");
const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
const localVideo = document.getElementById("localVideo");
localVideo.srcObject = stream;
cacheStream = stream;
} catch (error) {
errMsg = "getUserStream error ===> " + error.toString();
throw new Error(errMsg);
}
try {
// RTCPeerConnection.addTrack => 加入MediaStreamTrack
cacheStream
.getTracks()
.forEach((track) => peer.addTrack(track, cacheStream));
} catch (error) {
errMsg = "Peer addTransceiver error ===> " + error.toString();
throw new Error(errMsg);
}
}
開啟 WebRTC 連線
async function calling() {
try {
if (peer) {
alert("你已經建立連線!");
} else {
createPeerConnection(); //建立 RTCPeerConnection
await addStreamProcess(); // 加入多媒體數據到RTCPeerConnection instance
}
} catch (error) {
console.log(`Error ${error.name}: ${error.message}`);
}
}
建立 RTCPeerConnection
function createPeerConnection() {
console.log("create peer connection ...");
peer = new RTCPeerConnection();
peer.onicecandidate = handleIceCandidate; // 有新的ICE candidate 時觸發
peer.ontrack = handleRemoteStream; // connection中發現新的 MediaStreamTrack時觸發
peer.onnegotiationneeded = handleNegotiationNeeded;
}
這裡加入了三個event handler:
onicecandidate
: 當查找到相對應的遠端端口時會透過該事件來處理將 icecandidate 傳輸給 remote peers。ontrack
: 完成連線後,透過該事件能夠在發現遠端傳輸的多媒體檔案時觸發,來處理/接收多媒體數據。onnegotiationneeded
: 每當 RTCPeerConnection 要進行會話溝通(連線)時,第一次也就是在addTrack後會觸發該事件,傳送 icecandidate
function handleIceCandidate(event) {
socket.emit("icecandidate", event.candidate);
}
獲取新的多媒體數據
function handleRemoteStream(event) {
const remoteVideo = document.getElementById("remoteVideo");
if (remoteVideo.srcObject !== event.streams[0]) {
remoteVideo.srcObject = event.streams[0];
}
}
開始交涉(negotiation)
async function handleNegotiationNeeded() {
console.log("*** handleNegotiationNeeded fired!");
try {
console.log("start createOffer ...");
await peer.setLocalDescription(await peer.createOffer(offerOptions));
sendSDPBySignaling("offer", peer.localDescription);
} catch (error) {
console.log(`Error ${error.name}: ${error.message}`);
}
}
關閉連線 closing()
function closing() {
console.log("Closing connection call");
if (!peer) return; // 防呆機制
// 1. 移除事件監聽
peer.ontrack = null;
peer.onicecandidate = null;
peer.onnegotiationneeded = null;
// 2. 停止所有在connection中的多媒體信息
peer.getSenders().forEach((sender) => {
peer.removeTrack(sender);
});
// 3. 暫停video播放,並將儲存在src裡的 MediaStreamTracks 依序停止
const localVideo = document.getElementById("localVideo");
if (localVideo.srcObject) {
localVideo.pause();
localVideo.srcObject.getTracks().forEach((track) => {
track.stop();
});
}
// 4. cleanup: 關閉RTCPeerConnection連線並釋放記憶體
peer.close();
peer = null;
cacheStream = null;
}
上述程式用到以下幾個APIs :
搭配Signaling server的整體流程,跟之前單頁式的應用類似,
差別在於,SDP offer/answer以及ICE的溝通方式變動,
以及新認識了一個事件onnegotiationneeded
,確保將要進行會話的狀態,幫助我們更順暢的處理RTCPeerConnection連線流程。